Skip to content

refactor(levm): skip redundant bytecode bounds check in JUMP validation#6522

Open
MegaRedHand wants to merge 3 commits intomainfrom
perf/levm-jump-use-precomputed-targets
Open

refactor(levm): skip redundant bytecode bounds check in JUMP validation#6522
MegaRedHand wants to merge 3 commits intomainfrom
perf/levm-jump-use-precomputed-targets

Conversation

@MegaRedHand
Copy link
Copy Markdown
Collaborator

@MegaRedHand MegaRedHand commented Apr 23, 2026

Motivation

In jump(), target validity was checked twice:

  1. bytecode.get(target) to fetch the byte and verify in-bounds.
  2. Compare that byte against Opcode::JUMPDEST as u8.
  3. Binary search the position in jump_targets.

But Code::compute_jump_targets (in crates/common/types/account.rs) already scans the bytecode and only records positions whose byte is exactly JUMPDEST (0x5B) and that are not inside a PUSH literal. So a successful binary search already guarantees both:

  • the index is within the bytecode (in-bounds), and
  • the byte at that index is JUMPDEST.

The explicit get(target) bounds check and the byte comparison are therefore redundant.

Description

  • Collapse the validity check to a single jump_targets.binary_search(...).is_ok().
  • Drop the now-unused opcodes::Opcode import.
  • Keep the existing 3-line documentation above the check (it still describes the two invariants — they are now satisfied by jump_targets alone).
  • Sharpen the #[expect(clippy::as_conversions, reason = ...)] message to explain why the target as u32 cast is safe (bytecode length fits in u32).

Origin: cherry-picked optimization by @jrchatruc (commit dbf40f4), with the original documentation preserved.

`jump_targets` is built by scanning the bytecode and recording every
position that is a JUMPDEST (0x5B) and not inside a PUSH literal, so a
successful binary search already guarantees both in-bounds and the
JUMPDEST byte. Drop the extra `bytecode.get(target)` and `value ==
JUMPDEST` checks.
Copilot AI review requested due to automatic review settings April 23, 2026 19:13
@MegaRedHand MegaRedHand requested a review from a team as a code owner April 23, 2026 19:13
@github-actions github-actions Bot added levm Lambda EVM implementation performance Block execution throughput and performance in general labels Apr 23, 2026
@github-actions
Copy link
Copy Markdown

🤖 Kimi Code Review

Issue 1: Integer truncation bug in target casting (Line 422)

The cast target as u32 on line 422 (in the new code) can silently truncate on 64-bit platforms if target > u32::MAX. This could allow an invalid jump to a low address (e.g., 0) when attempting to jump to a high offset (e.g., 0x1_0000_0000), provided that low address is a valid jump target.

// Line ~422
.binary_search(&(target as u32))

Fix: Validate the conversion before casting:

let target_u32 = u32::try_from(target)
    .map_err(|_| VMError::InvalidJump)?;
if vm.current_call_frame.bytecode.jump_targets.binary_search(&target_u32).is_ok() {

Issue 2: Loss of defense-in-depth validation

The original code verified both that the target index contained the JUMPDEST opcode byte (0x5B) AND that it was in the jump_targets list. The new code relies solely on the jump_targets membership test.

While this is correct if the bytecode analysis that populates jump_targets is bug-free, blockchain clients should favor defense-in-depth. If jump_targets ever contains an invalid offset due to a bug in the analysis code, this change would allow jumping to non-JUMPDEST bytes or out-of-bounds offsets.

Suggestion: Either restore the explicit opcode check (at the cost of one bounds-checked memory access), or add an explicit bounds check target < bytecode.len() before the binary search to ensure the target is within the code range.

Issue 3: #[expect] justification comment (Line 419)

The updated comment states "safe: bytecode length fits in u32", but the cast is target as u32, not bytecode.len() as u32. While valid jump targets must be within bytecode bounds, target comes from user input (the stack) and could theoretically exceed u32::MAX before validation. The justification should reference the target value validation, not just bytecode length.

Positive: Removal of the unused Opcode import (line 26) is correct cleanup.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions
Copy link
Copy Markdown

🤖 Codex Code Review

  1. crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs:419-425 introduces a correctness bug for oversized jump targets. JUMP/JUMPI currently convert stack values with try_into().unwrap_or(usize::MAX); after this change, binary_search(&(target as u32)) truncates any usize > u32::MAX. That means an invalid target like 2^32 + x can alias a real JUMPDEST at x and be accepted. The previous bytecode.get(target) check rejected those cases. In consensus code this is not safe: it can turn an InvalidJump into successful execution. Use u32::try_from(target) before the search, or keep a bounds check like target < bytecode.len() and only then search jump_targets.

No other issues stood out in this diff. If you keep the optimization, I’d add a regression test for both JUMP and JUMPI with a target above u32::MAX/usize conversion fallback to ensure they still halt with InvalidJump.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 23, 2026

Greptile Summary

This PR collapses the jump() validity check from a three-step sequence (bytecode.get, byte comparison, binary search) to a single jump_targets.binary_search, relying on the invariants that compute_jump_targets only records positions whose byte is JUMPDEST and that are not inside a PUSH literal.

  • P1 — target as u32 truncation false positive: removing bytecode.get(target) also removes the only guard that prevented targets in the range (u32::MAX, usize::MAX] from aliasing a valid jump_targets entry after truncation. A JUMP to address 0x1_0000_0000 casts to u32 value 0; if position 0 is a JUMPDEST the jump incorrectly succeeds instead of returning InvalidJump.

Confidence Score: 3/5

Not safe to merge as-is — the removed bounds check was also the only guard against u32 truncation false-positives for jump targets > u32::MAX.

The optimization is logically sound for targets that fit in u32, but the removed bytecode.get(target) call was doing double duty: it was both the redundant JUMPDEST byte check and the sole protection against target as u32 aliasing a valid entry for any target value in (u32::MAX, usize::MAX]. This is a present correctness defect on 64-bit hosts, not a theoretical future risk.

crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs — the jump() function, specifically the target as u32 cast at line 424.

Important Files Changed

Filename Overview
crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs Removed the bytecode.get(target) bounds check from jump(), which was also the sole guard against target as u32 truncation for values > u32::MAX — a false-positive jump can now succeed for targets in (u32::MAX, usize::MAX] that alias a valid jump target after truncation.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["JUMP / JUMPI opcode\ntarget = U256::try_into().unwrap_or(usize::MAX)"] --> B["jump(vm, target: usize)"]

    subgraph OLD ["Old flow (removed)"]
        B --> C["bytecode.get(target)\n→ None if out-of-bounds"]
        C -- None --> D["InvalidJump ✗"]
        C -- Some(byte) --> E["byte == JUMPDEST?"]
        E -- No --> D
        E -- Yes --> F["binary_search(&(target as u32))"]
        F -- Err --> D
        F -- Ok --> G["pc = target + 1 ✓"]
    end

    subgraph NEW ["New flow (this PR)"]
        B --> H["binary_search(&(target as u32))"]
        H -- Err --> I["InvalidJump ✗"]
        H -- Ok --> J["pc = target + 1 ✓"]
    end

    style D fill:#f88,stroke:#c00
    style I fill:#f88,stroke:#c00
    style G fill:#8f8,stroke:#080
    style J fill:#8f8,stroke:#080

    K["⚠️ target > u32::MAX\ne.g. 0x1_0000_0000\n→ truncates to 0\n→ may alias valid entry"] -.->|"no longer rejected\nby get(target)"| H
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs
Line: 419-425

Comment:
**`target as u32` truncation opens false-positive path for oversized jump targets**

`target` arrives as `usize` (from `U256::try_into().unwrap_or(usize::MAX)`), so on a 64-bit host it can legally be `0x1_0000_0000` (value 4 294 967 296, one past `u32::MAX`). Casting that to `u32` yields `0`, and if position `0` in the bytecode happens to be a `JUMPDEST`, `jump_targets.binary_search(&0).is_ok()` returns `true` — the jump succeeds even though the requested target is wildly out of bounds.

The old `bytecode.get(target)` call prevented this: it performed a bounds check against the actual bytecode length (never ≥ 4 GB in practice), so any target > bytecode length was immediately rejected before the binary search ever ran.

The invariant stated in the PR description — *"a successful binary search already guarantees the index is within the bytecode"* — holds only when `target ≤ u32::MAX`. It breaks silently for larger values because `u32`-truncation can alias a valid entry in `jump_targets`.

Consider adding an explicit guard before the cast:

```rust
// Reject targets that can't possibly be a valid bytecode index before
// the u32 cast that `binary_search` requires.
if target > u32::MAX as usize {
    return Err(ExceptionalHalt::InvalidJump.into());
}
#[expect(clippy::as_conversions, reason = "safe: guarded above")]
if vm
    .current_call_frame
    .bytecode
    .jump_targets
    .binary_search(&(target as u32))
    .is_ok()
{
    ...
}
```

Alternatively, cast via `u32::try_from(target).ok()` and short-circuit on `None`.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "perf(levm): skip redundant bytecode boun..." | Re-trigger Greptile

Comment thread crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs Outdated
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 23, 2026

Lines of code report

Total lines added: 3
Total lines removed: 16
Total lines changed: 19

Detailed view
+------------------------------------------------------------------------+-------+------+
| File                                                                   | Lines | Diff |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/common/types/account.rs                                  | 330   | +3   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs | 310   | -16  |
+------------------------------------------------------------------------+-------+------+

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR optimizes LEVM JUMP/JUMPI validation by relying solely on the precomputed jump_targets list to determine whether a jump destination is valid, removing a redundant bytecode bounds/opcode check.

Changes:

  • Replace bytecode.get(target) + Opcode::JUMPDEST comparison with a single jump_targets.binary_search(...).is_ok().
  • Remove the now-unused opcodes::Opcode import.
  • Update the Clippy as-cast expectation reason for target as u32.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +419 to +425
#[expect(clippy::as_conversions, reason = "safe: bytecode length fits in u32")]
if vm
.current_call_frame
.bytecode
.bytecode
.get(target)
.is_some_and(|&value| {
value == Opcode::JUMPDEST as u8
&& vm
.current_call_frame
.bytecode
.jump_targets
.binary_search(&(target as u32))
.is_ok()
})
.jump_targets
.binary_search(&(target as u32))
.is_ok()
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

target as u32 can truncate on 64-bit platforms when the stack-provided jump target is in (u32::MAX, usize::MAX]. In that case binary_search may succeed for the truncated value even though the real target is out of bounds, and the VM will set pc to the large target (which later executes as STOP via CallFrame::next_opcode), changing InvalidJump semantics. Use a checked conversion (e.g., u32::try_from(target)) and treat conversion failure as an invalid jump, or explicitly reject target > u32::MAX before the binary search.

Copilot uses AI. Check for mistakes.
@github-actions
Copy link
Copy Markdown

🤖 Claude Code Review

Now I have all the context needed. Here is the review:


PR Review: perf(levm): skip redundant bytecode bounds check in JUMP validation

Overview

This PR removes a two-step validity check in jump() (a bytecode.get(target) bounds guard followed by a byte-equality check against JUMPDEST) in favour of a single binary_search into jump_targets. The motivation is correct: Code::compute_jump_targets only ever inserts a position when the byte there is exactly 0x5B and the position is not inside a PUSH literal, so a successful search already encodes both invariants. The restructuring is clean and the removal of the unused Opcode import is a nice side-effect.


Critical Issue — target as u32 Truncation for Large Targets

File: crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs, line 424

The old code:

bytecode.get(target).is_some_and(|&value| {
    value == Opcode::JUMPDEST as u8
        && jump_targets.binary_search(&(target as u32)).is_ok()
})

The bytecode.get(target) call served double duty:

  • It checked JUMPDEST byte identity.
  • It also acted as an implicit upper-bound guard: get() returns None whenever target >= bytecode.len() (at most ~24 576 bytes), making the whole condition false before the as u32 cast was ever reached.

The new code removes this guard entirely. On a 64-bit host, target: usize can be any value in [0, u64::MAX]. The EVM stack value is a U256, converted via try_into().unwrap_or(usize::MAX), so values in the range (u32::MAX, u64::MAX] are reachable.

Concrete exploit scenario:

A contract has a JUMPDEST at position X (e.g. X = 100). An attacker pushes 2^32 + 100 = 4_294_967_396 onto the stack and executes JUMP.

Step Old code New code
bytecode.get(4_294_967_396) None → condition false → InvalidJump (not called)
(4_294_967_396_usize as u32) never reached 100 — matches position in jump_targets
Result Err(InvalidJump) — correct Ok(()), pc = 4_294_967_397wrong

After the false positive, pc is set to a value far outside the bytecode. How the VM subsequently handles that out-of-bounds pc determines the full blast radius, but at minimum this is a deviation from the EVM specification (a JUMP to an out-of-range destination must produce an exceptional halt, not silently succeed).

Fix — use u32::try_from instead of as:

#[expect(clippy::as_conversions, reason = "...")]  // no longer needed once this is fixed
if u32::try_from(target).map_or(false, |t| {
    vm.current_call_frame
        .bytecode
        .jump_targets
        .binary_search(&t)
        .is_ok()
})

Or, equivalently, restore a cheap bounds check first:

if target < vm.current_call_frame.bytecode.bytecode.len()
    && vm.current_call_frame
        .bytecode
        .jump_targets
        .binary_search(&(target as u32))
        .is_ok()

Either approach eliminates the truncation hazard and removes the need for #[expect(clippy::as_conversions)] entirely.


Minor Points

  • The clippy suppression reason is misleading. "safe: bytecode length fits in u32" is a fact about Code::jump_targets entries, but target is a runtime EVM stack value — the two are unrelated. The comment implies the cast is safe, which it is not for target > u32::MAX.

  • No new test for the large-target edge case. A test that calls jump() with a target of u32::MAX as usize + 1 (where position 0 is a JUMPDEST) would pin the correct InvalidJump behaviour and prevent this regression from going unnoticed in the future.

  • Correctness of the core optimisation. The reasoning in the PR description is sound: compute_jump_targets guarantees that every stored index satisfies both the in-bounds and the JUMPDEST-byte conditions, so binary_search alone is sufficient for targets that fit in u32. The optimisation is valid once the truncation issue is addressed.

  • wrapping_add(1) on pc. Not introduced by this PR and not a regression here — since jump_targets can never contain u32::MAX for any practical bytecode, the only way target == usize::MAX could reach the success branch is through the truncation bug described above.


Summary

The optimisation idea is correct and the cleanup is welcome, but the removal of bytecode.get(target) inadvertently removed the implicit upper-bound guard that made target as u32 safe. For any EVM target value in (u32::MAX, u64::MAX] whose lower 32 bits coincide with a valid JUMPDEST position, the new code accepts the jump where the old code (and the spec) require an exceptional halt. This should be fixed with u32::try_from(target) before merging.


Automated review by Claude (Anthropic) · sonnet · custom prompt

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 23, 2026

Benchmark Results Comparison

No significant difference was registered for any benchmark run.

Detailed Results

Benchmark Results: BubbleSort

Command Mean [s] Min [s] Max [s] Relative
main_revm_BubbleSort 2.788 ± 0.015 2.775 2.822 1.08 ± 0.01
main_levm_BubbleSort 2.579 ± 0.007 2.569 2.591 1.00
pr_revm_BubbleSort 2.789 ± 0.016 2.775 2.824 1.08 ± 0.01
pr_levm_BubbleSort 2.586 ± 0.025 2.565 2.648 1.00 ± 0.01

Benchmark Results: ERC20Approval

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Approval 909.8 ± 4.3 905.2 919.5 1.00
main_levm_ERC20Approval 994.7 ± 16.1 983.0 1035.0 1.09 ± 0.02
pr_revm_ERC20Approval 910.5 ± 3.5 905.7 915.8 1.00 ± 0.01
pr_levm_ERC20Approval 988.1 ± 3.7 983.5 993.9 1.09 ± 0.01

Benchmark Results: ERC20Mint

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Mint 121.4 ± 1.0 120.3 122.9 1.00
main_levm_ERC20Mint 143.8 ± 0.8 143.1 145.0 1.18 ± 0.01
pr_revm_ERC20Mint 121.7 ± 1.7 120.5 125.4 1.00 ± 0.02
pr_levm_ERC20Mint 144.5 ± 2.7 143.0 151.8 1.19 ± 0.02

Benchmark Results: ERC20Transfer

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Transfer 217.2 ± 5.5 214.5 231.9 1.01 ± 0.03
main_levm_ERC20Transfer 243.0 ± 0.9 242.1 245.3 1.13 ± 0.01
pr_revm_ERC20Transfer 215.7 ± 1.5 214.2 218.4 1.00
pr_levm_ERC20Transfer 243.5 ± 1.6 242.0 247.5 1.13 ± 0.01

Benchmark Results: Factorial

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Factorial 185.0 ± 1.0 184.2 187.4 1.00 ± 0.01
main_levm_Factorial 209.6 ± 3.7 206.6 216.3 1.14 ± 0.02
pr_revm_Factorial 184.5 ± 0.7 183.8 186.2 1.00
pr_levm_Factorial 209.6 ± 2.3 206.5 213.4 1.14 ± 0.01

Benchmark Results: FactorialRecursive

Command Mean [s] Min [s] Max [s] Relative
main_revm_FactorialRecursive 1.315 ± 0.034 1.253 1.353 1.00 ± 0.03
main_levm_FactorialRecursive 1.719 ± 0.023 1.665 1.744 1.31 ± 0.03
pr_revm_FactorialRecursive 1.314 ± 0.029 1.274 1.353 1.00
pr_levm_FactorialRecursive 1.685 ± 0.021 1.659 1.723 1.28 ± 0.03

Benchmark Results: Fibonacci

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Fibonacci 152.7 ± 1.5 150.9 156.3 1.00
main_levm_Fibonacci 200.1 ± 30.6 185.6 284.8 1.31 ± 0.20
pr_revm_Fibonacci 153.3 ± 2.2 151.0 157.2 1.00 ± 0.02
pr_levm_Fibonacci 186.8 ± 1.4 185.5 189.0 1.22 ± 0.02

Benchmark Results: FibonacciRecursive

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_FibonacciRecursive 703.4 ± 2.3 700.4 708.1 1.19 ± 0.01
main_levm_FibonacciRecursive 590.0 ± 2.8 585.5 593.8 1.00
pr_revm_FibonacciRecursive 703.1 ± 8.0 692.5 717.8 1.19 ± 0.01
pr_levm_FibonacciRecursive 592.6 ± 9.5 584.0 613.9 1.00 ± 0.02

Benchmark Results: ManyHashes

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ManyHashes 7.9 ± 0.1 7.8 8.3 1.00
main_levm_ManyHashes 9.2 ± 0.2 9.1 9.8 1.16 ± 0.04
pr_revm_ManyHashes 8.0 ± 0.3 7.7 8.6 1.01 ± 0.04
pr_levm_ManyHashes 9.2 ± 0.2 9.1 9.5 1.17 ± 0.03

Benchmark Results: MstoreBench

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_MstoreBench 261.2 ± 11.4 255.5 292.8 1.25 ± 0.06
main_levm_MstoreBench 210.1 ± 2.4 207.3 215.9 1.00 ± 0.02
pr_revm_MstoreBench 256.8 ± 1.3 255.4 259.6 1.23 ± 0.02
pr_levm_MstoreBench 209.5 ± 4.0 206.9 220.1 1.00

Benchmark Results: Push

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Push 241.6 ± 0.7 240.0 242.5 1.02 ± 0.01
main_levm_Push 237.8 ± 1.4 236.5 241.4 1.00 ± 0.01
pr_revm_Push 241.5 ± 0.8 240.4 243.2 1.02 ± 0.01
pr_levm_Push 236.7 ± 1.1 235.5 238.2 1.00

Benchmark Results: SstoreBench_no_opt

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_SstoreBench_no_opt 151.9 ± 1.3 150.7 155.1 1.56 ± 0.01
main_levm_SstoreBench_no_opt 97.3 ± 0.3 97.0 98.0 1.00
pr_revm_SstoreBench_no_opt 151.4 ± 0.8 149.8 152.5 1.56 ± 0.01
pr_levm_SstoreBench_no_opt 97.9 ± 1.2 97.1 101.0 1.01 ± 0.01

Comment thread crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs Outdated
@github-project-automation github-project-automation Bot moved this to In Progress in ethrex_l1 Apr 23, 2026
@github-actions
Copy link
Copy Markdown

Benchmark Block Execution Results Comparison Against Main

Command Mean [s] Min [s] Max [s] Relative
base 60.380 ± 0.080 60.264 60.498 1.00 ± 0.00
head 60.255 ± 0.115 60.108 60.417 1.00

@MegaRedHand MegaRedHand changed the title perf(levm): skip redundant bytecode bounds check in JUMP validation refactor(levm): skip redundant bytecode bounds check in JUMP validation Apr 24, 2026
@github-actions github-actions Bot removed the performance Block execution throughput and performance in general label Apr 24, 2026
@MegaRedHand MegaRedHand requested a review from iovoid April 24, 2026 20:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

levm Lambda EVM implementation

Projects

Status: In Progress
Status: Todo

Development

Successfully merging this pull request may close these issues.

3 participants